Skip to content

Add board metrics dashboard (ANL-01)#667

Merged
Chris0Jeky merged 15 commits intomainfrom
feature/anl-01-board-metrics
Apr 1, 2026
Merged

Add board metrics dashboard (ANL-01)#667
Chris0Jeky merged 15 commits intomainfrom
feature/anl-01-board-metrics

Conversation

@Chris0Jeky
Copy link
Copy Markdown
Owner

Summary

Implements GitHub Issue #77 — ANL-01: Board metrics dashboard with throughput, cycle time, WIP, and blocked card trends.

Backend

  • BoardMetricsDtos.cs: Query/response DTOs for throughput data points, cycle time entries, WIP snapshots, and blocked card summaries
  • IBoardMetricsService / BoardMetricsService: Application-layer service that computes metrics from existing card, column, and audit data. Determines "done" column by rightmost position, calculates throughput by day, cycle time (creation to done), WIP per column, and blocked card duration
  • MetricsController: GET /api/metrics/boards/{boardId}?from=&to=&labelId= — authenticated, authorization-checked endpoint with sensible defaults (last 30 days)
  • DI registration: Wired with IAuthorizationService for read-access enforcement
  • 12 unit tests: Covering validation (empty IDs, invalid date ranges), permission denied, board not found, empty board metrics, WIP computation, blocked card counting, and static computation methods

Frontend

  • metrics.ts types: TypeScript interfaces matching backend DTOs
  • metricsApi.ts: HTTP client with proper URL encoding and query parameter handling
  • metricsStore.ts: Pinia store with loading/error/data states and demo mode support
  • MetricsView.vue: Full dashboard with:
    • Board selector and date range picker (7/14/30/60/90 days)
    • Summary cards (throughput total, avg cycle time, WIP count, blocked count)
    • CSS bar chart for throughput trend
    • Horizontal bar chart for WIP by column (with WIP limit violation highlighting)
    • Tables for cycle time details and blocked cards
    • Loading spinner, error state with retry, empty states
  • Route: /workspace/metrics with lazy loading
  • Navigation: "Metrics" item in sidebar (workbench secondary, available in guided/agent modes)
  • 4 API client tests: URL construction, query params, encoding

Test plan

  • Backend builds: dotnet build backend/Taskdeck.sln -c Release — 0 errors
  • Backend tests: dotnet test backend/Taskdeck.sln -c Release -m:1 — all pass (including 12 new metrics tests)
  • Frontend typecheck: npm run typecheck — clean
  • Frontend build: npm run build — successful
  • Frontend tests: npx vitest --run — 1495 tests pass (4 new metricsApi tests)
  • E2E: Manual verification of dashboard rendering with seeded data
  • Verify filter persistence across board selection changes

Closes #77

Copilot AI review requested due to automatic review settings April 1, 2026 21:08
@chatgpt-codex-connector
Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a comprehensive board metrics feature, including a new MetricsController, BoardMetricsService, and a corresponding frontend dashboard in Vue. The implementation covers key Agile metrics such as throughput, cycle time, work-in-progress (WIP), and blocked card tracking. While the architectural foundation is solid, several issues were identified regarding the accuracy of the metrics. Specifically, the service currently relies on the UpdatedAt timestamp for throughput, cycle time, and blocked duration calculations, which is unreliable as it changes with any card modification. The feedback highlights the need to utilize the already-queried audit logs to determine precise transition timestamps. Additionally, improvements are suggested for handling gaps in trend data, avoiding magic numbers for log limits, and optimizing frontend template logic.

Comment on lines +108 to +111
var completedCards = cards
.Where(c => c.ColumnId == doneColumn.Id
&& c.UpdatedAt >= from
&& c.UpdatedAt <= to)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Using c.UpdatedAt to determine completion time for throughput is unreliable. The UpdatedAt timestamp changes whenever any property of the card is modified (e.g., editing the description or adding a comment). To accurately calculate throughput, you should use the auditLogs to find the specific timestamp when the card transitioned into the doneColumn.

var entries = doneCards
.Select(c =>
{
var cycleTime = (c.UpdatedAt - c.CreatedAt).TotalDays;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Using c.UpdatedAt as the completion timestamp for cycle time is incorrect. If a user updates a card's details after it has already reached the 'Done' column, the cycle time will erroneously increase. This calculation should use the transition timestamp from the audit logs.

Comment on lines +69 to +74
var auditLogs = (await _unitOfWork.AuditLogs.QueryAsync(
query.From,
query.To,
boardId: query.BoardId,
limit: 10000,
cancellationToken: cancellationToken)).ToList();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The auditLogs variable is queried and populated here but is never actually used in the subsequent logic for computing throughput or cycle time. Based on the PR description, these logs are intended to be the source of truth for card transitions. Currently, the service is using UpdatedAt, which is unreliable.

query.From,
query.To,
boardId: query.BoardId,
limit: 10000,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The hardcoded limit of 10000 audit logs is a magic number that may be insufficient for boards with high activity over long date ranges, or potentially inefficient for smaller boards. Consider making this limit configurable or defining it as a named constant.

Comment on lines +115 to +121
var grouped = completedCards
.GroupBy(c => c.UpdatedAt.Date)
.Select(g => new ThroughputDataPoint(
new DateTimeOffset(g.Key, TimeSpan.Zero),
g.Count()))
.OrderBy(dp => dp.Date)
.ToList();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The throughput grouping only returns data points for dates where cards were completed. This results in 'gaps' in the trend data for days with zero completions, which can lead to misleading visualizations in the frontend chart. It is better to iterate through the date range and fill in missing dates with a CompletedCount of 0.

.Select(c =>
{
// Estimate blocked duration from UpdatedAt (when blocked was set) to now
var blockedDuration = (DateTimeOffset.UtcNow - c.UpdatedAt).TotalDays;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Estimating blocked duration using UpdatedAt is inaccurate. If a blocked card is updated (e.g., a comment is added) while it remains blocked, the UpdatedAt timestamp will refresh, causing the calculated duration to be significantly underestimated. The system should ideally track the specific BlockedAt timestamp.

Comment on lines +17 to +23
if (isDemoMode) {
loading.value = true
error.value = null
metrics.value = null
loading.value = false
return
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The demo mode implementation currently clears the metrics state and returns. This results in an empty dashboard for demo users. To provide a meaningful experience as suggested in the PR summary, this should populate the metrics state with realistic mock data.

<div class="td-metrics__card">
<span class="td-metrics__card-label">Total Throughput</span>
<span class="td-metrics__card-value">
{{ metrics.throughput.reduce((sum, d) => sum + d.completedCount, 0) }}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Performing a reduce operation directly inside the template can be inefficient as it may be re-evaluated more often than necessary. Consider moving this logic into a computed property (e.g., totalThroughput) for better performance and cleaner template code.

@Chris0Jeky
Copy link
Copy Markdown
Owner Author

Adversarial Self-Review

Issues found and fixed

  1. Unused audit log query (FIXED) - BoardMetricsService was loading up to 10,000 audit log entries but never using them in any computation. Metrics are computed purely from current card/column state. Removed the dead query to avoid wasted I/O. (commit 29630fb)

  2. Unused using Taskdeck.Domain.Enums import (FIXED) - Cleaned up in same commit.

Known limitations documented

  1. CardLabels eager loading dependency - Label filtering (query.LabelId) accesses card.CardLabels. If the ICardRepository.GetByBoardIdAsync implementation does not eagerly load CardLabels, the filter will silently return no matches. This is a pre-existing repository concern, not introduced by this PR. Follow-up: verify the EF Include chain in the infrastructure layer.

  2. DateTimeOffset.UtcNow in static ComputeBlocked - The blocked duration is computed against UtcNow inside a static method, which makes deterministic duration testing harder. Tests currently assert only count and title (not exact duration), which is sufficient. An IClock abstraction could be injected in a future iteration if needed.

  3. Throughput/cycle time heuristic - "Done" column is determined by the rightmost position. This is a reasonable default but boards with non-standard column layouts (e.g., a "Backlog" column at the far right) would misidentify the done column. Future enhancement: allow users to explicitly mark a column as "done" in column settings.

  4. No N+1 query risk - All data is loaded in bulk (columns by board, cards by board) and computation is done in-memory. No lazy-loading traps.

What looks good

  • Controller uses [Authorize] + TryGetCurrentUserId + CanReadBoardAsync authorization chain
  • All error paths return proper Result.Failure with appropriate error codes
  • Frontend has loading, error (with retry), and empty states
  • API client properly encodes board IDs with special characters
  • CSS-based charts avoid additional library dependencies
  • Route is lazy-loaded to keep initial bundle small

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new “Board Metrics” feature to Taskdeck, spanning a backend metrics computation endpoint and a frontend dashboard view for throughput, cycle time, WIP, and blocked-card reporting.

Changes:

  • Backend: introduce board-metrics DTOs, application service, and GET /api/metrics/boards/{boardId} endpoint with DI wiring and unit tests.
  • Frontend: add metrics types, API client + tests, Pinia store, and a new Metrics dashboard route/view wired into the sidebar.
  • UI: provide basic charts/tables and filter controls (board + relative date range).

Reviewed changes

Copilot reviewed 13 out of 13 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
frontend/taskdeck-web/src/views/MetricsView.vue New metrics dashboard UI (filters, summary cards, charts, tables, states).
frontend/taskdeck-web/src/types/metrics.ts TS interfaces for metrics query/response DTOs.
frontend/taskdeck-web/src/api/metricsApi.ts API client for fetching board metrics with query params.
frontend/taskdeck-web/src/tests/api/metricsApi.spec.ts Tests for metrics API URL/query construction + encoding.
frontend/taskdeck-web/src/store/metricsStore.ts Pinia store for loading/error/data state and demo-mode behavior.
frontend/taskdeck-web/src/router/index.ts Adds lazy-loaded /workspace/metrics route.
frontend/taskdeck-web/src/components/shell/ShellSidebar.vue Adds “Metrics” nav item to sidebar.
backend/src/Taskdeck.Application/DTOs/BoardMetricsDtos.cs Defines query/response records for board metrics.
backend/src/Taskdeck.Application/Services/IBoardMetricsService.cs Service contract for board metrics computation.
backend/src/Taskdeck.Application/Services/BoardMetricsService.cs Implements metrics computation and authorization enforcement.
backend/src/Taskdeck.Api/Controllers/MetricsController.cs Authenticated API endpoint for board metrics with defaults.
backend/src/Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs Registers IBoardMetricsService with authorization dependency.
backend/tests/Taskdeck.Application.Tests/Services/BoardMetricsServiceTests.cs Unit tests for validation and basic metric computations.
Comments suppressed due to low confidence (1)

backend/src/Taskdeck.Application/Services/BoardMetricsService.cs:187

  • Blocked duration is estimated using DateTimeOffset.UtcNow - c.UpdatedAt, but UpdatedAt changes for many unrelated card updates after a block is set (including label edits and moves), which will reset the duration and make the metric incorrect. Consider persisting a dedicated BlockedAt/UnblockedAt timestamp on the card, or computing durations from audit log history (e.g., parse AuditAction.Updated change summaries for "Blocked:" / "Unblocked"); also capture UtcNow once per request to keep results consistent within a response.
                return new BlockedCardSummary(
                    c.Id,
                    c.Title,
                    c.BlockReason,
                    Math.Round(blockedDuration, 2));
            })
            .OrderByDescending(b => b.BlockedDurationDays)
            .ToList();

        return (blockedCards.Count, blockedCards);
    }
}


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +68 to +84
var doneColumn = columns.OrderByDescending(c => c.Position).FirstOrDefault();

var throughput = ComputeThroughput(cards, doneColumn, query.From, query.To);
var (avgCycleTime, cycleTimeEntries) = ComputeCycleTime(cards, doneColumn, query.From, query.To);
var wipSnapshots = ComputeWip(columns, cards);
var totalWip = wipSnapshots.Sum(w => w.CardCount);
var (blockedCount, blockedCards) = ComputeBlocked(cards);

return Result.Success(new BoardMetricsResponse(
query.BoardId,
query.From,
query.To,
throughput,
avgCycleTime,
cycleTimeEntries,
wipSnapshots,
totalWip,
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

auditLogs are queried but never used, and throughput/cycle time are computed from the card’s current ColumnId and UpdatedAt. Since UpdatedAt is touched for many non-completion events (title/description edits, blocking/unblocking, reordering), this will miscount completions and distort cycle time. Consider deriving “entered done” timestamps from AuditLog entries (e.g., AuditAction.Moved with changes containing target_column=...) and using those timestamps for throughput bucketing and cycle-time calculations; if audit logs aren’t needed, remove the query to avoid wasted work.

Copilot uses AI. Check for mistakes.
Comment on lines +185 to +210
public void ComputeThroughput_ShouldReturnEmpty_WhenNoDoneColumn()
{
var result = BoardMetricsService.ComputeThroughput(
new List<Card>(),
null,
DateTimeOffset.UtcNow.AddDays(-7),
DateTimeOffset.UtcNow);

result.Should().BeEmpty();
}

[Fact]
public void ComputeCycleTime_ShouldReturnZero_WhenNoDoneColumn()
{
var (avg, entries) = BoardMetricsService.ComputeCycleTime(
new List<Card>(),
null,
DateTimeOffset.UtcNow.AddDays(-7),
DateTimeOffset.UtcNow);

avg.Should().Be(0);
entries.Should().BeEmpty();
}

[Fact]
public void ComputeWip_ShouldCountCardsPerColumn()
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tests only cover “no done column” cases for throughput/cycle time and don’t assert any positive scenarios where cards enter the done column. Adding tests that simulate card completion via AuditAction.Moved (and verify that subsequent non-move updates don’t change throughput/cycle time) would help prevent regressions and would catch issues caused by relying on Card.UpdatedAt/current ColumnId.

Copilot uses AI. Check for mistakes.
background: var(--td-color-ember);
border-radius: var(--td-radius-sm);
transition: width 0.3s ease;
min-width: 4px;
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The WIP bar fill has min-width: 4px, so columns with cardCount === 0 will still render a visible bar (misrepresenting zero WIP). Consider conditionally removing the min-width for zero values (or using min-width: 0 and relying on the count label) so the chart accurately reflects empty columns.

Suggested change
min-width: 4px;
min-width: 0;

Copilot uses AI. Check for mistakes.

<!-- Empty state -->
<div v-else-if="!hasData && canFetch" class="td-metrics__state">
<p>No metrics data available. Select a board to get started.</p>
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This empty-state copy is shown when a board is already selected (canFetch is true) but metrics is still null. “Select a board to get started” is misleading in that situation; consider changing the message to something like “No metrics available for this board/date range” (or a demo-mode-specific message if applicable).

Suggested change
<p>No metrics data available. Select a board to get started.</p>
<p>No metrics available for this board and date range.</p>

Copilot uses AI. Check for mistakes.
Coverage for branches was 70.26%, below the 71% global threshold.
Add unit tests for metricsStore (normal + demo mode) and MetricsView
(14 tests covering all template branches: loading, error, empty,
dashboard, empty charts, blocked alert, null block reason, watcher
triggers). Branch coverage now at 72%.
@Chris0Jeky
Copy link
Copy Markdown
Owner Author

Adversarial Review -- PR #667 (ANL-01: Board Metrics)

BLOCKING

1. Cycle time uses UpdatedAt as a proxy for "moved to done" -- fundamentally unreliable
BoardMetricsService.ComputeThroughput and ComputeCycleTime (lines 98-103, 127-131) filter cards with c.UpdatedAt >= from && c.UpdatedAt <= to to detect when a card reached the "done" column. However, UpdatedAt is mutated by any entity change (title edit, description change, block/unblock, label add) -- not just column moves. This means:

  • A card already in "done" that gets its title edited will be re-counted in throughput.
  • A card in "done" that had a label added after the query date range will be excluded from cycle time even though it was completed within the range.
  • Cycle time (c.UpdatedAt - c.CreatedAt) will overestimate if the card was edited after being moved to done.

Fix: Use the audit log (AuditEntry) to find the actual timestamp when ColumnId changed to the done column, or add a dedicated CompletedAt field to Card. Without this, the numbers are misleading.

2. "Done" column detection assumes rightmost column is always "done"
BoardMetricsService.cs line 68: var doneColumn = columns.OrderByDescending(c => c.Position).FirstOrDefault(). A board with columns [Backlog(0), In Progress(1), Review(2), Archive(3)] would treat "Archive" as done. A single-column board would treat that column as "done", counting all cards as completed. There is no way for users to designate which column is "done".

Fix: Either let users mark a column as "done" (domain change), or use a heuristic that checks the column name (fragile but better), or document this assumption prominently and add a parameter for the done column ID.

SHOULD-FIX

3. Interface placed in Services namespace instead of Interfaces
IBoardMetricsService is defined in Taskdeck.Application.Services namespace. The project convention is that interfaces live in Taskdeck.Application.Interfaces (see IAuthorizationService.cs in that namespace). The architecture tests may not catch this because the implementation also lives in Services, but it breaks the layer convention.

4. Constructor allows null authorization service -- auth bypass in production possible
BoardMetricsService has a public constructor BoardMetricsService(IUnitOfWork unitOfWork) that sets _authorizationService = null, and line 42 checks if (_authorizationService != null) before enforcing permissions. If someone accidentally registers the single-arg constructor in DI, all authorization is silently skipped. The null-auth constructor should be internal or removed, with the service always requiring IAuthorizationService.

5. All cards loaded into memory regardless of date range
GetByBoardIdAsync (line 59) loads every card for the board, then filters in-memory. For boards with 10,000+ cards, this is an O(n) memory load per request with no pagination. The throughput/cycle-time computations only need cards in the done column within the date range. Consider adding a repository method that filters by column and date range at the SQL level.

6. From >= To check should probably be From > To to allow same-day queries
Line 38: query.From >= query.To rejects queries where from == to. A user selecting "today" as both start and end date gets a validation error. This should likely be query.From > query.To.

7. Frontend: metricsStore swallows demo mode silently
In metricsStore.ts lines 17-23, demo mode sets metrics.value = null and returns. The user sees "No metrics data available" with no indication that this feature is unavailable in demo mode. Other stores (e.g., audit) handle demo mode by populating mock data. At minimum, show a message like "Metrics are not available in demo mode."

8. Blocked duration estimate is unreliable
ComputeBlocked (line 172) estimates blocked duration as DateTimeOffset.UtcNow - c.UpdatedAt. But UpdatedAt is touched by any change, not just Block(). If a blocked card has its title edited, the blocked duration resets to near-zero. Same root cause as issue #1.

MINOR

9. Throughput groups by c.UpdatedAt.Date -- timezone-unaware
UpdatedAt.Date uses the server's local timezone to bucket. A card completed at 23:59 UTC and another at 00:01 UTC would be in the same or different buckets depending on server timezone. Consider using c.UpdatedAt.UtcDateTime.Date consistently.

10. maxThroughput / maxWipCount computed uses Math.max(...spread) -- stack overflow risk
In MetricsView.vue lines 75-76 and 80-81, Math.max(...metrics.value.throughput.map(...)) will throw a RangeError if the array has more than ~65,000 elements (engine-dependent). Unlikely for daily granularity but worth noting.

11. No aria-live on dashboard content transitions
The dashboard uses v-if/v-else-if chains for loading/error/data states, but only the loading state has aria-live="polite". Screen readers may miss transitions to error or data states.

12. Frontend types use string for all IDs
Backend uses Guid but frontend types use string. This is fine and consistent with the rest of the codebase, but boardId in MetricsQuery accepts any string -- there is no UUID format validation on the client side before sending to the API.


Summary: The two BLOCKING issues (#1 and #2) mean the metric values displayed to users will frequently be incorrect. The core computation logic needs to use a more reliable signal for "card completed" than UpdatedAt, and the done-column detection needs to be explicit rather than assumed. I recommend fixing these before merge or clearly documenting the limitations in the UI itself.

…ristic, auth hardening

- Use audit log (AuditAction.Moved) to determine actual card completion
  timestamps for throughput and cycle time instead of unreliable UpdatedAt.
  Falls back to UpdatedAt when no audit data exists.
- Resolve done column by matching well-known names (done, complete, finished,
  closed, shipped, released) case-insensitively before falling back to
  rightmost column by position.
- Remove nullable IAuthorizationService constructor to prevent accidental
  auth bypass; authorization is now always enforced.
- Change From >= To validation to From > To to allow same-day queries.
- Show explicit "Metrics are not available in demo mode" message in frontend
  instead of silently returning null data.
- Use UtcDateTime.Date for throughput date bucketing to avoid timezone issues.
- Add tests: done column resolution, audit-based throughput/cycle time,
  ParseTargetColumnId, auth service error propagation, same-day query.
@Chris0Jeky Chris0Jeky merged commit 004fc68 into main Apr 1, 2026
23 checks passed
@Chris0Jeky Chris0Jeky deleted the feature/anl-01-board-metrics branch April 1, 2026 22:44
@github-project-automation github-project-automation bot moved this from Pending to Done in Taskdeck Execution Apr 1, 2026
Chris0Jeky added a commit that referenced this pull request Apr 1, 2026
Bump TESTING_GUIDE.md to 2026-04-02 and update verified totals and re-verification dates (backend 2053, frontend 1496, combined 3549+). Add detailed coverage notes for recent PRs: LLM Tool-Calling (PR #669), MCP Server (PR #664), GDPR Data Portability/Account Deletion (PR #666), Board Metrics (PR #667), and GitHub OAuth frontend tests (PR #668), including tracking issues, test targets, and brief manual validation steps. Record addition of a developer-portal CI artifact step (PR #658) and expand planned quality expectations to include the new services and API/store coverage for OAuth and metrics.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

ANL-01: Board metrics dashboard (throughput, cycle time, WIP, blocked trends)

2 participants